package org.io4j.data;

import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.io4j.util.Argument;

/**
 * Provides utilities for transferring data between streams or streams and other
 * data containers as well as for quietly closing {@code Closeable}s.
 */
public final class Streams {

	private static final int DEFAULT_BUFFER_SIZE = 8192;
	private static final String LINE_SEPARATOR = System.getProperty("line.separator");
	private static final int EOF = -1;

	private Streams() {
	}

	/**
	 * Provides a fluent interface for transferring data between byte and
	 * character streams. Using this interface one can specify source and target
	 * streams, character encoding and buffer size.
	 * 
	 * <pre>
	 * int bytesTransferred = Streams.transfer().from(InputStream).to(OutputStream);
	 * int charatersTransferred = Streams.transfer().from(Reader).usingEncoding(&quot;UTF-8&quot;)
	 * 		.usingBufferSize(102400).to(OutputStream);
	 * </pre>
	 * 
	 * Buffers data internally (with 8kB buffer if buffer size not specified) so
	 * using {@code BufferedInputStream} or {@code BufferedReader} is not
	 * required.
	 * 
	 * @return allows to provide further details about the data transfer
	 */
	public static DataTransfer transfer() {
		return new DataTransfer();
	}

	/**
	 * Provides a fluent interface for transferring data between byte and
	 * character streams. Using this interface one can specify source and target
	 * streams, character encoding and buffer size. Source and target streams are
	 * closed after the operation.
	 * 
	 * <pre>
	 * int bytesTransferred = Streams.transferAndClose().from(InputStream).to(OutputStream);
	 * int charatersTransferred = Streams.transferAndClose().from(Reader).usingEncoding(&quot;UTF-8&quot;)
	 * 		.usingBufferSize(102400).to(OutputStream);
	 * </pre>
	 * 
	 * Buffers data internally (with 8kB buffer if buffer size not specified) so
	 * using {@code BufferedInputStream} or {@code BufferedReader} is not
	 * required.
	 * 
	 * @return allows to provide further details about the data transfer
	 */
	public static DataTransfer transferAndClose() {
		return new DataTransfer().andClose();
	}

	/**
	 * Encapsulates the parameters of the data transfer operation and performs
	 * the operation on calling any of {@code to*()} methods.
	 */
	public static final class DataTransfer {

		private InputStream inputStream;
		private Reader reader;
		private Charset charset;
		private int bufferSize = DEFAULT_BUFFER_SIZE;
		private boolean shouldClose = false;

		DataTransfer() {
		}

		DataTransfer andClose() {
			this.shouldClose = true;
			return this;
		}

		/**
		 * Specifies the name of character set to be used while mapping bytes to
		 * characters or characters to bytes.
		 * 
		 * @throws IllegalArgumentException
		 *            if name of the charset is {@code null}
		 * @throws IllegalCharsetNameException
		 *            if name of the charset is illegal
		 * @throws UnsupportedCharsetException
		 *            if named charset is not supported
		 */
		public DataTransfer usingCharset(String charsetName) {
			Argument.isNotNull(charsetName, "Charset name is null");
			this.charset = Charset.forName(charsetName);
			return this;
		}

		/**
		 * Specifies a {@code Charset} to be used while mapping bytes to
		 * characters or characters to bytes.
		 * 
		 * @see Charset
		 * @throws IllegalArgumentException
		 *            if {@code Charset} is {@code null}
		 */
		public DataTransfer usingCharset(Charset charset) {
			Argument.isNotNull(charset, "Charset is null");
			this.charset = charset;
			return this;
		}

		/**
		 * Specifies a {@code CharsetEncoder} to be used while mapping characters
		 * to bytes.
		 * 
		 * @see CharsetEncoder
		 * @throws IllegalArgumentException
		 *            if {@code CharsetEncoder} is {@code null}
		 */
		public DataTransfer usingEncoder(CharsetEncoder charsetEncoder) {
			Argument.isNotNull(charsetEncoder, "Charset encoder is null");
			this.charset = charsetEncoder.charset();
			return this;
		}

		/**
		 * Specifies a {@code CharsetDecoder} to be used while mapping bytes to
		 * characters.
		 * 
		 * @see CharsetDecoder
		 * @throws IllegalArgumentException
		 *            if {@code CharsetDecoder} is {@code null}
		 */
		public DataTransfer usingDecoder(CharsetDecoder charsetDecoder) {
			Argument.isNotNull(charsetDecoder, "Charset decoder is null");
			this.charset = charsetDecoder.charset();
			return this;
		}

		/**
		 * Specifies a buffer size (in bytes or characters) to be used while
		 * transferring data. Buffer size of 8kB is used by default.
		 */
		public DataTransfer usingBufferSize(int bufferSize) {
			Argument.is(bufferSize > 0, "Buffer size has to be greater than zero (is " + bufferSize
					+ ")");
			this.bufferSize = bufferSize;
			return this;
		}

		/**
		 * Specifies a source {@code InputStream} to transfer bytes from. Use
		 * {@code from(Reader)} to transfer characters from a {@code Reader}
		 * instead.
		 * 
		 * @throws IllegalArgumentException
		 *            if {@code InputStream} is {@code null}
		 */
		public DataTransfer from(InputStream inputStream) {
			Argument.isNotNull(inputStream, "Input stream is null");
			this.inputStream = inputStream;
			return this;
		}

		/**
		 * Specifies a source {@code Reader} to transfer characters from. Use
		 * {@code from(InputStream)} to transfer bytes from an {@code InputStream}
		 * instead.
		 * 
		 * @throws IllegalArgumentException
		 *            if {@code Reader} is {@code null}
		 */
		public DataTransfer from(Reader reader) {
			Argument.isNotNull(reader, "Reader is null");
			this.reader = reader;
			return this;
		}

		/**
		 * Specifies a source byte array to transfer bytes from.
		 * 
		 * @throws IllegalArgumentException
		 *            if byte array is {@code null}
		 */
		public DataTransfer from(byte[] byteArray) {
			Argument.isNotNull(byteArray, "Byte array is null");
			this.inputStream = new ByteArrayInputStream(Arrays.copyOf(byteArray, byteArray.length));
			return this;
		}

		/**
		 * Specifies a source text to transfer characters from.
		 * 
		 * @throws IllegalArgumentException
		 *            if text is {@code null}
		 */
		public DataTransfer from(String text) {
			Argument.isNotNull(text, "Text is null");
			this.reader = new StringReader(text);
			return this;
		}

		/**
		 * Specifies a source list of lines to transfer characters from.
		 * 
		 * @throws IllegalArgumentException
		 *            if list of lines is {@code null}
		 */
		public DataTransfer from(List<String> lines) {
			Argument.isNotNull(lines, "List of lines is null");
			this.reader = new StringReader(joinWith(lines, LINE_SEPARATOR));
			return this;
		}

		private String joinWith(List<String> lines, String separator) {
			StringBuilder builder = new StringBuilder();
			boolean isFirst = true;
			for (String line : lines) {
				if (!isFirst) {
					builder.append(separator);
				}
				builder.append(line);
				isFirst = false;
			}
			return builder.toString();
		}

		/**
		 * Specifies an {@code OutputStream} to transfer data to and performs the
		 * data transfer using parameters provided thus far.
		 * <p>
		 * Use {@code from(InputStream)}, {@code from(Reader)},
		 * {@code from(byte[])}, {@code from(String)} or
		 * {@code from(List&lt;String&gt;)} to specify the source of data before
		 * calling this method.
		 * 
		 * @param outputStream
		 *           an {@code OutputStream} to transfer data to
		 * @return number of bytes or characters transferred
		 * @throws IllegalArgumentException
		 *            if {@code OutputStream} is {@code null}
		 * @throws IllegalStateException
		 *            if the source of data hasn't been previously specified
		 * @throws DataTransferException
		 *            if an error occurred during the operation
		 */
		public long to(OutputStream outputStream) {
			Argument.isNotNull(outputStream, "Output stream is null");
			checkIfDataSourceDefined();

			long numberOfBytesOrCharactersTransferred = 0;
			if (inputStream != null) {
				try {
					numberOfBytesOrCharactersTransferred = transfer(inputStream, outputStream,
							bufferSize);
				} finally {
					closeStreamsIfRequired(outputStream);
				}
			} else if (reader != null) {
				Writer writer = null;
				try {
					writer = createWriter(outputStream, charset);
					numberOfBytesOrCharactersTransferred = transfer(reader, writer, bufferSize);
				} finally {
					closeStreamsIfRequired(writer);
				}
			}
			return numberOfBytesOrCharactersTransferred;
		}

		private void checkIfDataSourceDefined() {
			if (reader == null && inputStream == null) {
				throw new IllegalStateException(
						"Define the source of data using 'from(InputStream)', 'from(Reader)', 'from(byte[])', 'from(String)' or 'from(List<String>)'");
			}
		}

		private void closeStreamsIfRequired(Closeable closeable) {
			if (shouldClose) {
				close(closeable, reader, inputStream);
			}
		}

		/**
		 * Specifies a {@code Writer} to transfer data to and performs the data
		 * transfer using parameters provided thus far.
		 * <p>
		 * Use {@code from(InputStream)}, {@code from(Reader)},
		 * {@code from(byte[])}, {@code from(String)} or
		 * {@code from(List&lt;String&gt;)} to specify the source of data before
		 * calling this method.
		 * 
		 * @param writer
		 *           a {@code Writer} to transfer data to
		 * @return number of bytes or characters transferred
		 * @throws IllegalArgumentException
		 *            if {@code Writer} is {@code null}
		 * @throws IllegalStateException
		 *            if the source of data hasn't been previously specified
		 * @throws DataTransferException
		 *            if an error occurred during the operation
		 */
		public long to(Writer writer) {
			Argument.isNotNull(writer, "Writer is null");
			checkIfDataSourceDefined();

			long numberOfBytesOrCharactersTransferred = 0;
			try {
				if (inputStream != null) {
					reader = createReader(inputStream, charset);
				}
				numberOfBytesOrCharactersTransferred = transfer(reader, writer, bufferSize);
			} finally {
				closeStreamsIfRequired(writer);
			}
			return numberOfBytesOrCharactersTransferred;
		}

		/**
		 * Performs the data transfer using parameters provided thus far and
		 * returns data as byte array.
		 * <p>
		 * Use {@code from(InputStream)}, {@code from(Reader)},
		 * {@code from(byte[])}, {@code from(String)} or
		 * {@code from(List&lt;String&gt;)} to specify the source of data before
		 * calling this method.
		 * 
		 * @return byte array containing data transferred from the source
		 * @throws IllegalStateException
		 *            if the source of data hasn't been previously specified
		 * @throws DataTransferException
		 *            if an error occurred during the operation
		 */
		public byte[] toByteArray() {
			checkIfDataSourceDefined();

			ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
			to(outputStream);
			return outputStream.toByteArray();
		}

		/**
		 * Performs the data transfer using parameters provided thus far and
		 * returns data as {@code String}.
		 * <p>
		 * Use {@code from(InputStream)}, {@code from(Reader)},
		 * {@code from(byte[])}, {@code from(String)} or
		 * {@code from(List&lt;String&gt;)} to specify the source of data before
		 * calling this method.
		 * 
		 * @return {@code String} containing data from the source
		 * @throws IllegalStateException
		 *            if the source of data hasn't been previously specified
		 * @throws DataTransferException
		 *            if an error occurred during the operation
		 */
		@Override
		public String toString() {
			checkIfDataSourceDefined();

			StringBuilder stringBuilder = new StringBuilder(bufferSize);
			char[] buffer = new char[bufferSize];
			int numberOfCharactersRead = 0;

			if (inputStream != null) {
				reader = createReader(inputStream, charset);
			}
			BufferedReader bufferedReader = new BufferedReader(reader, bufferSize);

			try {
				while (EOF != (numberOfCharactersRead = bufferedReader.read(buffer))) {
					stringBuilder.append(buffer, 0, numberOfCharactersRead);
				}
			} catch (IOException e) {
				throw new DataTransferException(String.format(
						"Reading data failed after %d characters", numberOfCharactersRead), e);
			} finally {
				closeStreamsIfRequired(bufferedReader);
			}
			return stringBuilder.toString();
		}

		/**
		 * Performs the data transfer using parameters provided thus far and
		 * returns data as a list of {@code String}s.
		 * <p>
		 * Use {@code from(InputStream)}, {@code from(Reader)},
		 * {@code from(byte[])}, {@code from(String)} or
		 * {@code from(List&lt;String&gt;)} to specify the source of data before
		 * calling this method.
		 * 
		 * @return a list of {@code String}s containing lines from the source
		 * @throws IllegalStateException
		 *            if the source of data hasn't been previously specified
		 * @throws DataTransferException
		 *            if an error occurred during the operation
		 */
		public List<String> toLineList() {
			checkIfDataSourceDefined();

			if (inputStream != null) {
				reader = createReader(inputStream, charset);
			}
			BufferedReader bufferedReader = new BufferedReader(reader, bufferSize);

			long numberOfCharactersRead = 0;
			List<String> lines = new ArrayList<String>();
			try {
				String line = bufferedReader.readLine();
				while (line != null) {
					lines.add(line);
					numberOfCharactersRead += line.length();
					line = bufferedReader.readLine();
				}
			} catch (IOException e) {
				throw new DataTransferException(String.format(
						"Reading data failed after %d characters", numberOfCharactersRead), e);
			} finally {
				closeStreamsIfRequired(bufferedReader);
			}
			return lines;
		}
	};

	private static Reader createReader(InputStream inputStream, Charset charset) {
		if (charset != null) {
			return new InputStreamReader(inputStream, charset);
		}
		return new InputStreamReader(inputStream);
	}

	private static Writer createWriter(OutputStream outputStream, Charset charset) {
		if (charset != null) {
			return new OutputStreamWriter(outputStream, charset);
		}
		return new OutputStreamWriter(outputStream);
	}

	/**
	 * Transfers bytes from {@code InputStream} to {@code OutputStream}. Buffers
	 * data internally (with 8kB buffer) so using {@code BufferedInputStream} is
	 * not required.
	 * 
	 * @param inputStream
	 *           the {@code InputStream} to read from
	 * @param outputStream
	 *           the {@code OutputStream} to write to
	 * @return the number of bytes transferred
	 * @throws IllegalArgumentException
	 *            if {@code InputStream} or {@code OutputStream} is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public static long transfer(InputStream inputStream, OutputStream outputStream) {
		return transfer(inputStream, outputStream, DEFAULT_BUFFER_SIZE);
	}

	/**
	 * Transfers bytes from {@code InputStream} to {@code OutputStream}. Buffers
	 * data internally (with buffer size provided) so using
	 * {@code BufferedInputStream} is not required.
	 * 
	 * @param inputStream
	 *           the {@code InputStream} to read from
	 * @param outputStream
	 *           the {@code OutputStream} to write to
	 * @param bufferSize
	 *           the size of a buffer to use
	 * @return the number of bytes transferred
	 * @throws IllegalArgumentException
	 *            if {@code InputStream} or {@code OutputStream} is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public static long transfer(InputStream inputStream, OutputStream outputStream, int bufferSize) {
		Argument.isNotNull(inputStream, "Input stream is null");
		Argument.isNotNull(outputStream, "Output stream is null");

		byte[] buffer = new byte[bufferSize];
		long totalBytesTransferred = 0;
		int bytesRead = 0;
		try {
			while (EOF != (bytesRead = inputStream.read(buffer))) {
				outputStream.write(buffer, 0, bytesRead);
				totalBytesTransferred += bytesRead;
			}
		} catch (IOException e) {
			throw new DataTransferException(String.format("Copying streams failed after %d bytes",
					totalBytesTransferred), e);
		}
		return totalBytesTransferred;
	}

	/**
	 * Transfers characters from {@code Reader} to {@code Writer}. Buffers data
	 * internally (with 8kB buffer) so using {@code BufferedReader} is not
	 * required.
	 * 
	 * @param reader
	 *           the {@code Reader} to read from
	 * @param writer
	 *           the {@code Writer} to write to
	 * @return the number of characters transferred
	 * @throws IllegalArgumentException
	 *            if {@code Reader} or {@code Writer} is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public static long transfer(Reader reader, Writer writer) {
		return transfer(reader, writer, DEFAULT_BUFFER_SIZE);
	}

	/**
	 * Transfers characters from {@code Reader} to {@code Writer}. Buffers data
	 * internally (with buffer size provided) so using {@code BufferedReader} is
	 * not required.
	 * 
	 * @param reader
	 *           the {@code Reader} to read from
	 * @param writer
	 *           the {@code Writer} to write to
	 * @param bufferSize
	 *           the size of a buffer to use
	 * @return the number of characters transferred
	 * @throws IllegalArgumentException
	 *            if {@code Reader} or {@code Writer} is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public static long transfer(Reader reader, Writer writer, int bufferSize) {
		Argument.isNotNull(reader, "Reader is null");
		Argument.isNotNull(writer, "Writer is null");

		char[] buffer = new char[bufferSize];
		long totalCharactersTransferred = 0;
		int charactersRead = 0;
		try {
			while (EOF != (charactersRead = reader.read(buffer))) {
				writer.write(buffer, 0, charactersRead);
				totalCharactersTransferred += charactersRead;
			}
			writer.flush();
		} catch (IOException e) {
			throw new DataTransferException(String.format(
					"Copying streams failed after %d characters", totalCharactersTransferred), e);
		}
		return totalCharactersTransferred;
	}

	/**
	 * Unconditionally closes a {@code Closeable} without throwing any
	 * exceptions. Typically used in {@code finally} blocks.
	 * 
	 * @param closeable
	 *           the object to close, may be {@code null} or already closed
	 */
	public static void close(Closeable closeable) {
		try {
			if (closeable != null) {
				closeable.close();
			}
		} catch (IOException ignored) {
		}
	}

	/**
	 * Unconditionally closes multiple {@code Closeable}s without throwing any
	 * exceptions. Typically used in {@code finally} blocks.
	 * 
	 * @param closeables
	 *           objects to close, may be {@code null} or already closed
	 */
	public static void close(Closeable... closeables) {
		for (Closeable closeable : closeables) {
			close(closeable);
		}
	}
}
